<?php
/**
 * FormChecker
 *
 * An open source data form checker for web user input
 * php version must large than 5.2
 * F_date only available on *nix platforms.
 *
 * @author  Kangell <leikou@163.com>
 * @copyright Copyright (c) 2011, Kangell 
 * @link    http://code.google.com/p/form-check/
 * @version   1.0
 */

/*
 * $Id: form_check.php 31 2011-09-26 12:13:56Z leikou@163.com $
 */

/**
 * coding: utf-8
 * check form data format
 *
 * <code>
 * <?php
 * $source: $_GET OR $_POST.
 * $input_format = {
 *      'name' => FC_str('abc', 'default-value')->min(2)->max(32),
 *      'age' => FC_int()->min(0)->max(200),
 *      'choices' => FC_int()->min(1)->max(10)
 *          ->multi()->required() 
 *          ->callback(
 *              function($v) {
 *                  return array(TRUE, $v);
 *              }
 *          ),
 *      'email' => FC_email('邮件')->msg('{{ name }} xxxxx'),
 *      'name' => FC_str('chinese_name', 'default_value')->max(10)
 *          ->min(1)->multi()->required()->msg('{name}: xxxx')-exp('pattern'),
 * }
 *
 * checker = new Form_checker;
 *
 * checker.check($_GET, $input_format);
 * checker.get_valid_data();
 * checker.get_raw_data();
 * checker.get_error_message();
 * ?>
 */


/**
 * Base class for form data format checking. Forbidden instance directly.
 */
class Form_checker {
    private $_is_valid;
    private $_error_message;
    private $_valid_data;
    private $_raw_data;


    public function __construct() {
        $this->_init();
    }


    private function _init() {
        $this->_is_valid = TRUE;
        $this->_error_message = array();
        $this->_valid_data = array();
        $this->_raw_data = array();
    }


    /**
     * @param array $source: data source, dict.
     * @param array $input_format: form setting
     *
     * @return bool
     */
    public function check($source, $input_format, $encoding="utf-8") {
        $this->_init();

        foreach($input_format as $field_name => $checker) {
            // Set encoding
            $checker->set_encoding($encoding);

            // Get data
            $value = isset($source[$field_name]) ? $source[$field_name] : NULL;
            // Check format
            list($is_valid, $this->_raw_data[$field_name], $v, $m) = $checker->check($field_name, $value);
            if($is_valid) {
                $this->_valid_data[$field_name] = $v;
            } else {
                $this->_error_message[$field_name] = $m;
            }

            $this->_is_valid = $this->_is_valid && $is_valid;
        }

        return $this->_is_valid;
    }


    public function get_valid_data() {
        return $this->_valid_data;
    }

    public function get_raw_data() {
        return $this->_raw_data;
    }


    /* Get data format error msg
     *
     * @return: array.
     */
    public function get_error_message() {
        return $this->_error_message;
    }

}



/**
 * <code>
 * <?php
 *  FC_int('name', 2)->min(1)->max(2)->multi()
 *      ->required()->callback(
 *          function($v) { 
 *              return array(TRUE, $v);
 *          }
 *      )->msg('{{ name }} xxxxx');
 * ?>
 *
 * @param string $field_name var name
 * @param mixed $default_value
 *
 * @return F_int
 */
function FC_int($field_name = NULL, $default_value = NULL) {
    return new F_int($field_name, $default_value);
}


/**
 * <code>
 * <?php
 *  FC_str('name')->min(1)->max(2)->multi()
 *      ->required()->strict()
 *      ->re('/^\d{2,3}_\w{8}$/')
 *      ->callback(
 *          function($v) { 
 *              return array(TRUE, $v);
 *          }
 *      )->msg('{{ name }} xxxxx');
 * ?>
 *
 * @param string $field_name var name
 * @param mixed $default_value
 *
 * @return F_str
 */
function FC_str($field_name = NULL, $default_value = NULL) {
    return new F_str($field_name, $default_value);
}


/**
 * <code>
 * <?php
 *  FC_email('name')->multi()->re('/@163.com$/')
 *      ->required()->callback(
 *          function($v) { 
 *              return array(TRUE, $v);
 *          }
 *      )->msg('{{ name }} xxxxx');
 * ?>
 *
 * @param string $field_name var name
 * @param mixed $default_value
 *
 * @return F_email
 */
function FC_email($field_name = NULL, $default_value = NULL) {
    return new F_email($field_name, $default_value);
}


/**
 * <code>
 * <?php
 *  FC_phone('name', 2)->multi()->re('/^028/')
 *      ->required()->callback(
 *          function($v) { 
 *              return array(TRUE, $v);
 *          }
 *      )->msg('{{ name }} xxxxx');
 * ?>
 *
 * @param string $field_name var name
 * @param mixed $default_value
 *
 * @return F_phone
 */
function FC_phone($field_name = NULL, $default_value = NULL) {
    return new F_phone($field_name, $default_value);
}


/**
 * <code>
 * <?php
 *  FC_mobile('name', 2)->multi()->re('/^189/')
 *      ->required()->callback(
 *          function($v) { 
 *              return array(TRUE, $v);
 *          }
 *      )->msg('{{ name }} xxxxx');
 * ?>
 *
 * @param string $field_name var name
 * @param mixed $default_value
 *
 * @return F_mobile
 */
function FC_mobile($field_name = NULL, $default_value = NULL) {
    return new F_mobile($field_name, $default_value);
}


/**
 * <code>
 * <?php
 *  FC_ip('name', 2)->multi()->re('/^192/')
 *      ->required()->callback(
 *          function($v) { 
 *              return array(TRUE, $v);
 *          }
 *      )->msg('{{ name }} xxxxx');
 * ?>
 *
 * @param string $field_name var name
 * @param mixed $default_value
 *
 * @return F_ip
 */
function FC_ip($field_name = NULL, $default_value = NULL) {
    return new F_ip($field_name, $default_value);
}


/**
 * <code>
 * <?php
 *  FC_date('name', '%Y/%m%d')->multi()
 *      ->re('/^2011/')
 *      ->required()->callback(
 *          function($v) { 
 *              return array(TRUE, $v);
 *          }
 *      )->msg('{{ name }} xxxxx');
 * ?>
 *
 * @param string $field_name var name
 * @param mixed $default_value
 *
 * @return F_date
 */
function FC_date(
    $field_name = NULL, 
    $format = 'Y-m-d',
    $default_value = NULL
) {
    return new F_date($field_name, $format, $default_value);
}


/**
 * <code>
 * <?php
 *  FC_chs('name')->multi()
 *      ->re('/^[\x80]$/')
 *      ->required()->callback(
 *          function($v) {
 *              return array(TRUE, $v);
 *          }
 *      )->msg('{{ name }} xxxxxx');
 * ?>
 *
 * @param string $field_name var name
 * @param mixed $default_value
 * 
 * @return F_chs
 */
function FC_chs (
    $field_name = NULL,
    $default_value = NULL
) {
    return new F_chs($field_name, $default_value);
}


/**
 * Format check base class. Defined basic workflow
 *
 * @abstract
 */
abstract class Input {
    protected static $_error_message = array(
        'max' => '{{name}}最大值为{{max}}',
        'min' => '{{name}}最小值为{{min}}',
        'max_len' => '{{name}}最大长度为{{max_len}}个字符',
        'min_len' => '{{name}}最小长度为{{min_len}}个字符',
        'blank' => '{{name}}不能为空',
        'format' => '{{name}}格式有错',
        'default' => '{{name}}格式有错',
    );

    protected $_max = NULL;
    protected $_min = NULL;
    protected $_required = FALSE;
    protected $_strict = FALSE; // means empty string will treat as no input
    protected $_multiple = FALSE;
    protected $_callback = array();
    
    protected $_default_value = NULL;
    protected $_msg_vars = array();
    protected $_custom_error_msg = NULL;
    protected $_msg_key = NULL;
    protected $_encoding = "utf-8";
    

    /**
     * @param string $field: var real name (such as chinese name)
     * @param mixed $default_value: default value
     */
    public function __construct($field_name = NULL, $default_value = NULL) {
        $this->_default_value = $default_value;
        $this->_msg_vars = array(
            'name' => $field_name,
        );
    }


    /**
     * @param string $encoding
     *
     */
    public function set_encoding($encoding = "utf-8")
    {
        $this->_encoding = $encoding;
    }


    /**
     * @param integers $max_value: if type of var is integers, 
     *      specify max value. if type of var is strings, 
     *      specify str max length
     *
     * @exception InvalidArgumentException: when param type is not int, throw.
     */
    public function max($max_value) {
        if( ! is_int($max_value)) {
            throw new InvalidArgumentException(
                "Form_check error: checker method max() expect a 'integer' ".
                "argument, but supply '".gettype($max_value)."'"
            );
        }

        $this->_max = $max_value;

        return $this;
    }


    /**
     * @param integers $min_value: if type of var is integers, 
     *      specify min value. if type of var is strings, 
     *      specify str min length
     *
     * @exception InvalidArgumentException: when param type is not int, throw.
     */
    public function min($min_value) {
        if( ! is_int($min_value)) {
            throw new InvalidArgumentException(
                "Form_check error: checker method min() expect a 'integer' ".
                "argument, but supply '".gettype($min_value)."'"
            );
        }

        $this->_min = $min_value;

        return $this;
    }


    /**
     * Indicate var must be set (NULL)
     */
    public function required() {
        $this->_required = TRUE;

        return $this;
    }


    /**
     * Means empty string be treated NULL
     */
    public function strict() {
        $this->_strict = TRUE;

        return $this;
    }


    /**
     * Indicate var must be one-dimensional array
     */
    public function multi() {
        $this->_multiple = TRUE;

        return $this;
    }


    /**
     * @param string $msg: custom defined message.
     *      Associate array. key must be in self::_error_message
     *
     * @exception InvalidArgumentException: when param type is not string, throw.
     */
    public function msg($msg) {
        if( ! is_string($msg)) {
            throw new InvalidArgumentException(
                "Form_check error: checker method msg() expect a 'string' ".
                "argument, but supply '".gettype($msg)."'"
            );
        }

        $this->_custom_error_msg = $msg;

        return $this;
    }


    /**
     * @param function $func: user defined callback function. The
     *      prototype as below:
     *      array function(mixed $v);  
     *      $v: variable need checked
     *      return: array(boolean, $v);
     *
     *      such as:
     *      function($v) {
     *          return (TRUE, $v);
     *      }
     *
     *      class A {
     *          static function foo($v) {
     *              return (TRUE, $v);
     *          }
     *      }
     *
     *      F_xxx->callback(function($v) { return (TRUE, $v); })
     *      F_xxx->callable(array('A', 'foo'))
     *
     * @exception InvalidArgumentException: when param type is not callable, throw.
     */
    public function callback($func) {
        if(is_callable($func)) {
            $this->_callback[0] = $func;
        } else {
            throw new InvalidArgumentException(
                "Form_check error: checker method callback() expect a 'callable' ".
                "argument, but supply '".gettype($msg)."'"
            );
        }

        return $this;
    }

    /**
     * @param string $name: field name of the form data
     * @param mixed $value: field value. array or string.
     * 
     * @return array: a numeric of 4 elements:
     *      the first indicates wether the value is valid or not
     *      the second is the raw data of user input
     *      the third is the valid data or, if not valid, is NULL
     *      the last is error message or, if valid, is NULL
     *
     * @exception InvalidArgumentException: when param $name's 
     *      type is not string, throw.
     */
    public function check($name, $value) {
        if( ! is_string($name)) {
            throw new InvalidArgumentException(
                "Form_check error: checker method ".__CLASS__."::check() expect ".
                "a 'string' argument, but supply '".gettype($name)."'"
            );
        }

        if($this->_msg_vars['name'] === NULL) {
            $this->_msg_vars['name'] = $name;
        }

        if($this->_multiple) {
            return $this->_check_multi($value);
        } else {
            return $this->_check_value($value);
        }
    }


    /**
     * @param mixed $value: data be checked. string 
     */
    abstract protected function _check($value);


    protected function _check_multi($values) {
        $values = $values === NULL ? array() : $values;
        if( ! is_array($values))
        {
            $err_tpl = self::$_error_message['default'];
            $err_msg = self::_str_template($err_tpl, $this->_msg_vars);

            return array(FALSE, $values, NULL, $err_msg);
        }

        $valid_datas = array();
        foreach($values as $value) {
            list($is_valid, $raw, $valid_data, $err_msg) = $this->_check_value($value);
            if( ! $is_valid) {
                return array(FALSE, $values, NULL, $err_msg);
            }

            if($valid_data !== NULL)
            {
                $valid_datas[] = $valid_data;
            }
        }

        if(empty($valid_datas) && $this->_required) {
            $err_tpl = self::$_error_message['blank'];
            $err_msg = self::_str_template($err_tpl, $this->_msg_vars); 

            return array(FALSE, $values, NULL, $err_msg);
        }

        return array(TRUE, $values, $valid_datas, NULL);
    }


    /**
     * @param string $raw: value need to be checked.
     *
     * @exception InvalidArgumentException: when param $raw's 
     *      type is not string and not NULL, throw.
     */
    protected function _check_value($raw) {
        if($raw !== NULL && ! is_string($raw)) {
            throw new InvalidArgumentException(
                "Form_check error: checker method ".__CLASS__."::_check_value() ".
                "expect a 'string' argument, but supply '".gettype($raw)."'"
            );
        }

        $is_valid = FALSE;
        $valid_data = NULL;
        $err_msg = NULL;

        // trim begin and end space
        if($raw !== NULL) {
            $value = trim($raw);
        } else {
            $value = $raw;
        }

        if($value === NULL || ($value === '' && $this->_strict)) {
            if($this->_default_value !== NULL) {
                return array(TRUE, $raw, $this->_default_value, NULL);    
            } else {
                $value = NULL;
            }
        }
        
        // NULL means the client does not send the field's value,
        // this differs from empty string 
        if($value !== NULL) {
            list($is_valid, $data) = $this->_check($value);

            if($is_valid) {
                if(isset($this->_callback[0])) {
                    list($is_valid, $data) = call_user_func($this->_callback[0], $data);
                    if($is_valid) {
                        $valid_data = $data;       
                    } else {
                        $err_msg = self::$_error_message['default'];
                    }
                } else {
                    $valid_data = $data;
                }
            } else {
                $err_msg = $data;
            }
        } else if( ! $this->_multiple && $this->_required) {
            $err_msg = self::$_error_message['blank']; 
        } else {
            $is_valid = TRUE; 
            $valid_data = $value;
        }

        if($err_msg !== NULL) {
            if($this->_custom_error_msg) {
                $err_msg = $this->_custom_error_msg;
            }

            $err_msg = self::_str_template($err_msg, $this->_msg_vars);
        }

        return array($is_valid, $raw, $valid_data, $err_msg);
    }


    protected function _check_mm($value) {
        if($this->_max !== NULL && $value > $this->_max) {
            $this->_msg_key = 'max';
            $this->_msg_vars['max'] = $this->_max;
            $this->_msg_vars['max_len'] = $this->_max;

            return FALSE;
        }

        if($this->_min !== NULL && $value < $this->_min) {
            $this->_msg_key = 'min';
            $this->_msg_vars['min'] = $this->_min;
            $this->_msg_vars['min_len'] = $this->_min;

            return FALSE;
        }

        return TRUE;
    }


    public static function _str_template($err_msg, $vars) {

        return preg_replace_callback(
            '/\{\{\s*(\S*?)\s*\}\}/',
            function($match) use ($vars) {
                if(isset($vars[$match[1]])) {
                    return $vars[$match[1]];
                } else {
                    return '';
                }
            },
            $err_msg
        );
    }
}



class F_int extends Input {
    protected $_strict = TRUE;


    protected function _check($value) {
        if( ! $this->_is_int($value)) {
            $err_msg = self::$_error_message['format'];

            return array(FALSE, $err_msg);
        }

        $value = intval($value);
            
        if( ! $this->_check_mm($value)) {
            $err_msg = self::$_error_message[$this->_msg_key];

            return array(FALSE, $err_msg);
        }

        return array(TRUE, $value);
    }


    private function _is_int($value) {
        $pattern = '/^[-+]?\d+$/';

        return preg_match($pattern, $value) !== 0;
    }
}



class F_str extends Input {
    protected $_pattern = NULL;


    /**
     * Set Regular expression
     * 
     * @param strings $pattern
     *
     * @exception InvalidArgumentException: when param type is not string, throw.
     */
    public function re($pattern) {
        if( ! is_string($pattern)) {
            throw new InvalidArgumentException(
                "Form_check error: checker ".__CLASS__."::method re() ".
                "expect a 'string' argument, but supply '".gettype($pattern)."'"
            );
        }

        $this->_pattern = $pattern;

        return $this;
    }


    protected function _check_regexp($value) {
        return preg_match($this->_pattern, $value) > 0;
    }


    protected function _check($value) {
        $value = @(string)$value;

        // Check function mb_strlen is exists
        if( ! function_exists('mb_strlen'))
        {
            trigger_error('extention: mbstring must active', E_USER_ERROR);
        }

        if( ! $this->_check_mm(mb_strlen($value, $this->_encoding))) {
            $err_msg = self::$_error_message[$this->_msg_key.'_len'];

            return array(FALSE, $err_msg);
        }

        if($this->_pattern) {
            if( ! $this->_check_regexp($value)) {
                $err_msg = self::$_error_message['default'];
                return array(FALSE, $err_msg);
            }
        }

        return array(TRUE, $value);
    }
}



class F_email extends F_str {
    protected $_strict = TRUE;


    protected function _check($value) {
        list($is_valid, $data) = parent::_check($value);

        if( ! $is_valid) {
            return array($is_valid, $data);
        }

        return $this->_is_email($data);
    }


    protected function _is_email($value) {
        if(filter_var($value, FILTER_VALIDATE_EMAIL) === FALSE) {
            return array(FALSE, self::$_error_message['format']);
        }

        return array(TRUE, $value);
    }
}



/**
 * Check phone format.
 * allow format as: 
 *      028-85333333
 *      028 85333333
 *      02885333333
 *
 *  when format pass, remove charactor '-' and ' '
 */
class F_phone extends F_str {
    protected $_strict = TRUE;


    protected function _check($value) {
        list($is_valid, $data) = parent::_check($value);

        if( ! $is_valid) {
            return array($is_valid, $data);
        }

        return $this->_is_phone($data);
    }


    protected function _is_phone($value) {
        $pattern = '/^0\d{2,3}[-\s]?\d{6,8}$/';
        if(preg_match($pattern, $value) === 0) {
            return array(FALSE, self::$_error_message['format']);
        }

        $value = str_replace(
            array('-', ' '),
            array('', ''),
            $value
        );

        return array(TRUE, $value);
    }
}



class F_mobile extends F_str {
    protected $_strict = TRUE;


    protected function _check($value) {
        list($is_valid, $data) = parent::_check($value);

        if( ! $is_valid) {
            return array($is_valid, $data);
        }

        return $this->_is_mobile($data);
    }


    protected function _is_mobile($value) {
        $pattern = '/^1\d{10}$/';
        if(preg_match($pattern, $value) === 0) {
            return array(FALSE, self::$_error_message['format']);
        }

        return array(TRUE, $value);
    }
}



class F_ip extends F_str {
    protected $_strict = TRUE;


    protected function _check($value) {
        list($is_valid, $data) = parent::_check($value);

        if( ! $is_valid) {
            return array($is_valid, $data);
        }

        return $this->_is_ip($data);
    }


    protected function _is_ip($value) {
        if(filter_var($value, FILTER_VALIDATE_IP) === FALSE) {
            return array(FALSE, self::$_error_message['format']);
        }

        return array(TRUE, $value);
    }
}


class F_date extends F_str {
    protected $_date_format;

    public function __construct(
        $field_name = NULL, 
        $format='Y-m-d',
        $default_value = NULL
    ) {
        parent::__construct($field_name, $default_value);

        $this->_date_format = $format;
        $this->_strict = TRUE;
    }


    protected function _check($value) {
        list($is_valid, $data) = parent::_check($value);

        if( ! $is_valid) {
            return array($is_valid, $data);
        }

        return $this->_validate_date($data);
    }


    protected function _validate_date($date_str) {
        try
        {
            $datetime = new Datetime($date_str);
        }
        catch (Exception $e)
        {
            return array(FALSE, self::$_error_message['format']);
        }
        
        return array(TRUE, $datetime->format($this->_date_format));
    }
}



/**
 * Ask input value must be chinese character
 */
class F_chs extends F_str {
    protected $_strict = TRUE;


    protected function _check($value) {
        list($is_valid, $data) = parent::_check($value);

        if( ! $is_valid) {
            return array($is_valid, $data);
        }

        return $this->_only_chinese($data);
    }


    protected function _only_chinese($value) {
        $pattern = "/^[\x80-\xff]*$/";

        $utf8_value = mb_convert_encoding($value, 'utf-8', $this->_encoding);

        if( ! preg_match($pattern, $utf8_value)) {
            return array(FALSE, self::$_error_message['format']);
        }

        return array(TRUE, $value);
    }
}



/**
 * @todo
class F_idcard extends F_str {

}
 */



/* End of form_check.php */
